Google CTF 2022: JS Safe 4.0 (rev)
問題文
You stumbled upon someone's "JS Safe" on the web. It's a simple HTML file that can store secrets in the browser's localStorage. This means that you won't be able to extract any secret from it (the secrets are on the computer of the owner), but it looks like it was hand-crafted to work only with the password of the owner...
問題概要
localStorageにデータを暗号化して保存できるアプリケーションが与えられる。このアプリケーションはある暗号化キー(フラグ)専用に作られており、その暗号化キーを解読する問題。ただしアンチデバッグ機能付き。 解法
与えられたjs_safe_4.htmlをブラウザで開くとアプリケーションが開く。フィールドに適当に文字列を入力するとAccess Deniedになる。このフィールドに正しいフラグを入力すると、Access Grantedと表示されそうということがソースコードからわかる。
https://gyazo.com/ca4b6a0e8b4a3dc76ba41c71c2dac03e
また、ソースコードをVSCodeで開いたら普通ではない行終端記号が検出されましたとアラートが表示され、コード自体に特殊なUnicode文字が存在して、なんらかの難読化が行われていそうということがわかる。
https://gyazo.com/49ee52970c84c79b97ed186ccd4f75f0
とりあえずスクリプトの挙動を調べるために動的解析しようとChrome Developer Toolsを開いたら固まった。ページのタイトルにJS safe v4 - the leading localStorage based safe solution with amazing JS anti-debug technologyとあるように、アンチデバッグ機能を持っている模様。 どのコードがデバッグを妨害しているのかを探す。二分探索的に半分コメントアウトして挙動がどう変わるかを調べるのを繰り返すと楽。すると次のコードが原因であるとわかった。 code:js
// splice for Object (generalization of Array.prototype.splice)
function splice(start, deleteCount, insert) {
ret = {};
// Need to iterate backwards to avoid indexing problems around deleted properties
for (i = deleteCount; i != 0; i--) key = Object.keys(this)start+i-1, retkey = thiskey, delete thiskey; for (key in insert) thiskey = insertkey; return ret;
}
var a = {a:1, b:2, c:3}; console.log('splice test', JSON.stringify({returnValue: splice.call(a, 0, 2, {a:5, b:6}), updatedObject: a}));
Object.defineProperty(Object.prototype, 'splice', {get:splice});
最後のdefinePropertyをコメントアウトするとDeveloper Toolsを開いてデバッグできるようになった。
なぜ動かないのかを調べる。まず、Array.prototype.splice(start, deleteCount, ...items)関数は、startからdeleteCountを削除し、代わりにitemsを挿入するというもの。このArray.prototype.spliceの一般化として、Object.prototype.splice関数を独自に実装している。
Array.prototype.spliceのECMAScriptの定義を読むと、startやdeleteCountが存在しない場合でも動作するようにしなければならない。しかし今回の独自実装では、deleteCountが与えられない場合、for文のループが無限ループに陥る。JavaScriptでは引数が足りなくてもエラーは出ずundefinedになるため、deleteCountが与えられないとiがundefinedになり、iがデクリメントされるとNaNに変化して永遠に終わらないようになる。これがDeveloper Toolsを開いたときに発動してしまうと実験でわかった。 ということで、spliceを独自実装にアップデートせずに進める。コードからspliceを検索すると、code変数内で用いている。この箇所を実行されたらエラーを吐くだろうが、そのときはアドホックに対処すれば問題ない。
open_safe関数でフラグチェックをしているが、チェックの核はx関数である。x関数は次のコードで定義されている。
code:js
var code = `\x60
console.log({flag});
for (i=0; i<100; i++) setTimeout('debugger');
if ("\x24\x7B\x22 .? K 7 hA [Cdml<U}9P @dBpM) -$A%!X5 '% U(!_ (c 4zp$RpUi(mv!u4!D%i%6!D'Af$Iu8HuCP>qH.*(Nex.)X&{I'$ ~Y0mDPL1 U08<2G{ ~ _:h\ys! K A( f.'0 p!s fD] ( H E < 9Gf.' XH,V1 P * -P\x22\x7D" != ("\x24\x7B\x22" + checksum(code) + "\x22\x7D")) while(1); flag = flag.split('');
i = 1337;
pool = 'c_3L9zKw_l1HusWN_b_U0c3d5_1'.split('');
while (pool.length > 0) if(flag.shift() != pool.splice((i = (i || 1) * 16807 % 2147483647)%pool.length, 1)0) return false; return true;
\x60`;
setTimeout("x = Function('flag', " + code + ")");
Function()は関数のコンストラクタであり関数オブジェクトを生成する。普通は動的に関数を生成したいときに使う。flagは第一引数になる変数名で、codeはfunction bodyである。ここを解析していけば良さそう。
静的解析で次のことがわかる。
for (i=0; i<100; i++) setTimeout('debugger');は100回debugger文を呼び出すことでデバッガーを妨害する。 if文でchecksum関数を用いてcodeが改変されていないかのチェックを行っており、また、特殊文字により隠れた何らかの処理が行われている。
i = 1337;のiの後ろに特殊文字が隠れており変数名がASCIIコードだけで構成されたiではない。
pool.spliceは先程の独自splice関数が適用されている。
隠れた処理がどういうものかを探るためにxを呼び出すところにブレークポイントを置いてステップインすると、xのコードが表示される。pretty printすると次のコードが表示される。
code:js
(function anonymous(flag) {
console.log({
flag
});
for (i = 0; i < 100; i++)
setTimeout('debugger');
if (" .? K 7 hA [Cdml<U}9P @dBpM) -$A%!X5 '% U(!_ (c 4zp$RpUi(mv!u4!D%i%6!D'Af$Iu8HuCP>qH.*(Nex.)X&{I'$ ~Y0mDPL1 U08<2G{ ~ _:hys! K A( f.'0 p!s fD] ( H E < 9Gf.' XH,V1 P * -P" != (" .? K 7 hA [Cdml<U}9P 8 @@\di "), i += (x + "").length + 12513 | 1,
!("X HA 8 @ ( D 7 1 @# C+]CeC QG AZ (!s! K hA_P G{ ~ _:h\ys! K A( f.0 p!s fD] ( H E < 9Gf.` XH,V1 P * -P"))
while (1)
;
flag = flag.split('');
i = 1337;
pool = 'c_3L9zKw_l1HusWN_b_U0c3d5_1'.split('');
while (pool.length > 0)
if (flag.shift() != pool.splice((i = (i || 1) * 16807 % 2147483647) % pool.length, 1)0) return false;
return true;
}
)
静的解析からはわからなかったi += (x + "").length + 12513 | 1が出てきた。この変数名iはASCIIコードのみで構成されている。debuggerを無視するために、「範囲」の「グローバル」からi = 99に変え処理を進めると、iが13337になっているとわかる。i = 13337のもとでpoolから構成されるflagを取り出す。
code:py
i = 13337
pool = list("c_3L9zKw_l1HusWN_b_U0c3d5_1")
flag = ""
for _ in range(len(pool)):
i = (i * 16807) % 2147483647
j = i % len(pool)
flag += x
print(flag)
するとW0w_5ucH_N1c3_d3bU9_sK1lLz_が出てきた。これをCTF{}でラップしてフィールドに入力するとAccess Grantedと表示されたので意気揚々でフラグを提出するも間違いだった。なにか見落としているらしい。よく考えたら最後が_で終わっているのも変。
色々と調べていると、改変していない大元のファイルでこのフラグを試すとAccess Deniedになった。どうやらdefinePropertyをコメントアウトしたことで挙動が変わったらしい。さらに調べるとDeveloper Toolsで以下の部分に謎の赤丸があることに気づく。
https://gyazo.com/096a45e665532e13de76f9436f7ab36f
カーソルを合わせると、Control character line separatorと表示された。どうやらこれはU+2028でJavaScriptで改行を表していることがわかった。ECMAScriptの定義のLine Terminatorsにもしっかり書いてある。pretty printすると改行に変換され隠れた処理が現れた。 https://gyazo.com/b24b45829a09254df83159fc11a6fe1d
document.documentElement.outerHTML.lengthの結果が異なると何らかの処理が発火しないことがわかる。checksum関数の戻り値である文字列をsetTimeoutで実行している。そこで、checksum(' ' + checksum)の式を評価したい。'' + checksum(' ' + checksum)をコンソールに入力すると、次のコードが表示された。
code:js
pA:Object.defineProperty(document/* +d({*/.body,'className',{get(){/* */return this.getAttribute('class'/* @*/)||''},set(x){this.setAttribute(/* 7 , @@X(tw Y */'class',(x!='granted'/* ,5 @*/||(/* s |L Q4 *//* s |L M *//* Se h@*//* ( ) N) H 5! =X*//* +d v=A (( *//* (*//^CTF{([0-9a-zA-Z_@!?-]+)}$/.exec(/* * ]#*/keyhole.value)||x)[1].endsWith/* 9 */('Br0w53R_Bu9s_C4Nt_s70p_Y0u'))/* ? [mRP+d X*/?x:'denied')}})/* *///
読みづらいが、どうやらフラグの中身がBr0w53R_Bu9s_C4Nt_s70p_Y0uで終わらないとAccess Deniedとなるようになっていた模様。よって、先程得られた前半の文字列とこれを組み合わせるとフラグが得られた。
Flag: CTF{W0w_5ucH_N1c3_d3bU9_sK1lLz_Br0w53R_Bu9s_C4Nt_s70p_Y0u}
余談
checksumの生成部分
generate関数で生成している。payloadが隠したいコード。そのままじゃ実行時にエラーが出る(原因はchecksumがhostFnになっているなど)ので、ちょっと修正して実行。checksum関数の処理がgenerate関数の処理と対応しているから、checksumを変更することはできない。